banner
Fei_xiangShi

FXLOG

你在这里发现了我, 说明了什么呢?

Rust winit Iced wayland 應用最小化

起因:Wayland 上沒有 set_visible#

在開發 Rustle(自己的音樂軟體)時,我需要實現 "最小化到系統托盤" 功能:

  • 點擊關閉按鈕時,窗口隱藏而不是退出程序
  • 點擊托盤圖標時,窗口重新顯示
  • 程序在後台繼續運行(daemon 模式)

雖然使用 daemon 模式可以做到後台運行,但是 Iced 和 winit 的默認策略是關掉整個窗口,再需要時再喚出,但是由於我打算使用 GPU 渲染而不是軟渲染,所以創建一個新的 GPU 上下文再重新初始化 vulkan 生命週期再調用 wgpu 繪製軟體界面,這個冷啟動的過程足足有 500ms!所以必須保存 vulkan/opengl 生命週期,則不能銷毀窗口,而是令其不可見。

在 X11 上,這很簡單 —— 調用 window.set_visible(false/true) 即可。但在 Wayland 上:

// winit 的 Wayland 實現
pub fn set_visible(&self, _visible: bool) {
    // Not possible on Wayland.
}

winit 直接放棄了這個功能,註釋寫著 "Wayland 上不可能實現"。(?怎麼可能)

查閱 Wayland 協議文檔後發現,Wayland 的設計哲學與 X11 截然不同:

  • 沒有全局窗口管理器 API:客戶端不能直接操作窗口的顯示狀態
  • Compositor 主導一切:窗口的顯示、隱藏、位置都由 compositor 決定
  • 只有 set_minimized:但這個操作是單向的—— 程序無法通過代碼恢復最小化的窗口

翻遍全網也沒找到一樣的問題。但真的沒有辦法嗎?


探索:GTK、Chromium 是怎麼做的?#

GTK 的實現#

查看 GTK 源碼發現了關鍵線索:

// gdk/wayland/gdkwindow-wayland.c
static void gdk_wayland_window_hide(GdkWindow *window) {
    GdkWindowImplWayland *impl = GDK_WINDOW_IMPL_WAYLAND(window->impl);
    
    wl_surface_attach(impl->display_server.wl_surface, NULL, 0, 0);
    wl_surface_commit(impl->display_server.wl_surface);
    _gdk_window_clear_update_area(window);
}

關鍵發現:GTK 通過 wl_surface_attach(NULL) 來隱藏窗口!

XDG Shell 協議規範#

查閱 XDG Shell 協議文檔,找到了官方說明:

Attaching a null buffer to a toplevel unmaps the surface.

The client can re-map the toplevel by performing a commit without any buffer attached, waiting for a configure event and handling it as usual.

這意味著:

  • 隱藏attach(NULL) + commit() → surface 被 unmap
  • 顯示commit() → 觸發 configure event → 重新渲染

Chromium 的實現#

進一步研究 Chromium 的 Wayland 實現:

// WaylandToplevelWindow::Hide()
void WaylandToplevelWindow::Hide() {
    shell_toplevel_.reset();  // 銷毀 xdg_toplevel
    connection()->buffer_manager_host()->ResetSurfaceContents(root_surface());
}

// WaylandToplevelWindow::Show()
void WaylandToplevelWindow::Show(bool inactive) {
    if (!CreateShellToplevel()) { ... }  // 重新創建 xdg_toplevel
}

Chromium 採用了更激進的方案 —— 銷毀並重建 xdg_toplevel。但我後來發現這種方式在 Hyprland 上會導致 compositor 崩潰(這對嗎?)。


實現:修改 winit#

架構概覽#

┌────────────────────────────┐
│                    Rustle (應用層)                     │
├────────────────────────────┤
│                    iced (GUI 框架)                     │
├────────────────────────────┤  
│                 iced_winit (窗口管理)                  │
├────────────────────────────┤
│                    winit (窗口抽象)                    │
├────────────────────────────┤  
│         smithay-client-toolkit (Wayland 封裝)          │
├────────────────────────────┤
│              wayland-client (協議綁定)                 │
├────────────────────────────┤
│                  Wayland Compositor                    │
└────────────────────────────┘

需要修改的層:

  1. iced: 添加 set_visible API
  2. winit: 實現 Wayland 上的 set_visible

winit 的修改#

核心實現src/platform_impl/linux/wayland/window/mod.rs):

pub fn set_visible(&self, visible: bool) {
    // 根據 XDG Shell 協議:
    // - "Attaching a null buffer to a toplevel unmaps the surface."
    // - "The client can re-map the toplevel by performing a commit without any
    //    buffer attached, waiting for a configure event and handling it as usual."

    let surface = self.window.wl_surface();

    if visible {
        {
            let mut state = self.window_state.lock().unwrap();
            state.set_visible(true);
            // 重置 frame callback 狀態,打破死鎖
            state.frame_callback_reset();
        }

        surface.commit();
        self.request_redraw();
    } else {
        self.window_state.lock().unwrap().set_visible(false);
        
        // 清空待處理的 redraw 請求
        self.window_requests.redraw_requested.store(false, Ordering::Relaxed);

        // 按協議 unmap:attach(NULL) + commit
        surface.attach(None, 0, 0);
        surface.commit();
    }
}

iced 的修改#

添加 Actionruntime/src/window.rs):

pub enum Action {
    // ...existing actions...
    
    /// 設置窗口的可見性。
    SetVisible(Id, bool),
}

/// 設置窗口的可見性。
pub fn set_visible<T>(id: Id, visible: bool) -> Task<T> {
    task::effect(crate::Action::Window(Action::SetVisible(id, visible)))
}

處理 Actionwinit/src/lib.rs):

window::Action::SetVisible(id, visible) => {
    if let Some(window) = window_manager.get_mut(id) {
        window.raw.set_visible(visible);
    }
}

不是哥們:那些令人頭疼的 Bug#

Hyprland Compositor 崩潰(為什麼不能銷毀 xdg_toplevel?)#

最初的方案:參考 Chromium 的實現,銷毀 xdg_toplevel 來隱藏窗口,重建它來顯示窗口。

問題:在 Hyprland 上,銷毀並重建 xdg_toplevel 會導致 compositor 崩潰,回到 SDDM 界面

// Hyprland 崩潰堆棧
CWindow::create(CXDGSurfaceResource)
CWLSurface::assign
CWLSurface::init  // 崩潰點

根本原因:Hyprland 不能正確處理在同一個 xdg_surface 上重新創建 xdg_toplevel 的情況。

最終方案:完全避免銷毀 xdg_toplevel,只使用 XDG Shell 協議規定的 wl_surface.attach(NULL) 方法:

  • 隱藏:attach(NULL) + commit() → surface 被 unmap
  • 顯示:commit() + request_redraw() → 重新渲染

這個方案:

  1. 完全符合 XDG Shell 協議
  2. 不破壞 xdg_toplevel 生命週期
  3. 兼容所有 compositor(包括 Hyprland)
  4. 代碼更簡潔,不需要複雜的生命週期管理

隱藏後無法恢復顯示#

現象set_visible(false) 成功隱藏窗口,但 set_visible(true) 後窗口不出現。

原因:Frame callback 死鎖。

┌───────────────────────────┐
│  wgpu 等待 frame callback 才能提交 buffer            │
│                        ↓                            │
│  compositor 等待 buffer 才能發送 frame callback      │
│                        ↓                            │
│                       死鎖!                         │
└───────────────────────────┘

當窗口隱藏(attach(NULL))後,compositor 不再發送 frame callback。但 winit 的渲染循環依賴 frame callback 來知道何時渲染下一幀。

解決方案:在 set_visible(true) 時重置 frame callback 狀態:

state.frame_callback_reset();  // 重置為 None,允許立即重繪

隱藏時窗口閃爍#

現象set_visible(false) 時窗口消失後又閃現一次,而且每次閃現的次數會累積。

原因:Client-Side Decorations (CSD) 的刷新邏輯。

winit 的事件循環會周期性調用 refresh_frame() 來更新窗口裝飾。即使窗口已經隱藏,如果 CSD 框架認為自己是 "dirty" 的,它仍然會觸發重繪 —— 這會重新 attach buffer,導致窗口又出現。

解決方案:多層防護:

// 1. refresh_frame() 中檢查 visible
pub fn refresh_frame(&mut self) -> bool {
    if !self.visible {
        return false;  // 隱藏時不刷新裝飾
    }
    // ...
}

// 2. request_redraw() 中檢查 visible
pub fn request_redraw(&self) {
    if !self.window_state.lock().unwrap().visible() {
        return;  // 隱藏時不請求重繪
    }
    // ...
}

// 3. set_visible(false) 時清空 pending redraw
self.window_requests.redraw_requested.store(false, Ordering::Relaxed);

// 4. event loop 派發時檢查 visible
if !window.visible() {
    window_requests.get(window_id).unwrap().take_redraw_requested();
    return None;  // 不派發 RedrawRequested
}

最終方案總結#

核心原理#

隱藏窗口:
┌────────────────────────┐
│ 1. set_visible(false)                          │
│ 2. 設置 visible 狀態為 false                   │
│ 3. 清空 pending redraw 請求                    │
│ 4. wl_surface.attach(NULL, 0, 0)               │
│ 5. wl_surface.commit()                         │
│ → Surface 被 unmap,compositor 不再顯示它     │
└────────────────────────┘

顯示窗口:
┌────────────────────────┐
│ 1. set_visible(true)                           │
│ 2. 設置 visible 狀態為 true                    │
│ 3. 重置 frame_callback_state (打破死鎖)        │
│ 4. wl_surface.commit()                         │
│ 5. request_redraw()                            │
│ → 觸發重繪,wgpu attach buffer,窗口重新出現  │
└────────────────────────┘

修改的文件#

項目文件修改內容
winitsrc/.../wayland/window/mod.rs實現 set_visible();在 request_redraw() 中檢查 visible
winitsrc/.../wayland/window/state.rs添加 visible 字段;在 refresh_frame() 中檢查 visible
winitsrc/.../wayland/event_loop/mod.rs在 RedrawRequested 派發前檢查 visible 並清空 pending redraw
icedruntime/src/window.rs添加 SetVisible action 和 set_visible() 函數
icedwinit/src/lib.rs處理 SetVisible action,調用 window.raw.set_visible()

使用方式#

// 在 iced 應用中
Message::ToggleWindow => {
    self.window_hidden = !self.window_hidden;
    let visible = !self.window_hidden;
    
    return iced::window::latest().and_then(move |id| {
        iced::window::set_visible(id, visible)
    });
}

參考資料#

協議文檔#

源碼參考#

相關 Issue#


作者注:本實現基於 winit 0.30.12、iced 0.14.0。不同版本可能需要調整。

代碼倉庫:

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。